﻿#region Namespace Imports


// Standard class imports
using System;
using System.Collections.Generic;
using System.Text;

// Provides easier access to the EventLog and associated types
using System.Diagnostics;

// Eases access to localization and globalization types
using System.Globalization;

// To simplify working with files and paths
using System.IO;

// For access to some declarative security types
using System.Security.Permissions;

// To make it easy to get implementer info for log writing
using System.Reflection;

// Needed to work with sitemap files
using System.Xml;
using System.Xml.XPath;

// Simplifies access to SharePoint types
using Microsoft.SharePoint;
using Microsoft.SharePoint.Administration;
using Microsoft.SharePoint.Security;
using Microsoft.SharePoint.Utilities;


#endregion Namespace Imports


namespace SPMcDonough.Moss.BlobCacheFarmFlush
{


    /// <summary>
    /// This SPJobDefinition is responsible for updating the layouts.sitemap file in each
    /// of the (IIS) web sites within the <c>SPWebApplication</c> to which the current timer
    /// job instance is assigned.  If this isn't done, then the navigational hierarchy doesn't
    /// get updated; the result is a missing breadcrumb on newly-added LAYOUTS pages.
    /// <para>Though the <c>SPWebService</c> type contains an <c>ApplyApplicationContentToLocalServer</c>
    /// method that updates the navigational hierarchy, there are two important considerations
    /// with this method.  First, the "Local" part of the method name is important; in a multi-
    /// server farm, the navigational hierarchy is only updated on the server that is actively
    /// running the code.  Second, execution of the method requires that the caller be a member
    /// of the local admin group.  In a properly-configured (that is, least privileges mode of
    /// operation) farm, no SharePoint account is going to be a member of the local admin group
    /// on any server.</para>
    /// </summary>
    [SharePointPermission(SecurityAction.LinkDemand, ObjectModel=true),
     SharePointPermission(SecurityAction.InheritanceDemand, ObjectModel=true)]
    public class UpdateLayoutsSitemapTimerJob : SPJobDefinition
    {


        #region Constants


        // Constants are used here instead of assembly resources in order to avoid all
        // dependencies for this class.  The thinking is that this class could be lifted
        // as-is an re-used in any application/Feature/solution without requiring that
        // other resources or types also be ported.
        private const String FORMAT_JOB_NAME = "applylayoutssitemaptimerjob-{0}";
        private const String FORMAT_JOB_TITLE = "One-Time LAYOUTS SiteMap Update for {0}";

        // The following are the XML constants that will be needed for sitemap operations
        private const String XML_ATTRIBUTE_PARENT = "parentUrl";
        private const String XML_ATTRIBUTE_URL = "url";
        private const String XPATH_FOR_DELETE = @"siteMapNode[translate(@url, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') = '{0}']";
        private const String XPATH_SELECT_ADD_NODES = @"siteMap/siteMapNode";
        private const String XPATH_SELECT_PARENT = @"//siteMapNode[translate(@url, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') = '{0}']";

        // The following are relative filepaths used by this class.
        private const String PATH_REL_LAYOUTS = @"TEMPLATE\LAYOUTS";           // relative to 12-hive
        private const String PATH_REL_SITEMAP = @"_app_bin\layouts.sitemap";   // relative to inetpub site


        #endregion Constants


        #region Member Declarations


        // The following is the backing property value for the sitemap file identifier.  This
        // file will be looked-up on target servers (in the LAYOUTS folder) and used as the
        // source for sitemap navigation updates.
        [Persisted]
        private String _siteMapFilename;


        #endregion Member Declarations


        #region Constructors


        /// <summary>
        /// Default constructor implementation; simply a pass-through.  In the event that
        /// we were to add persisted properties, this constructor would be needed for
        /// serialization support.
        /// </summary>
        public UpdateLayoutsSitemapTimerJob()
            : base()
        { }


        /// <summary>
        /// This is the constructor that we'll be using to actually create instances of our
        /// timer job.  We'll be tying instances of the timer job to specific web applications
        /// (on specific servers), so we'll use the overload that supports this mode of operation.
        /// </summary>
        public UpdateLayoutsSitemapTimerJob(String name, SPWebApplication webApplication, SPServer server, SPJobLockType lockType)
            : base(name, webApplication, server, lockType)
        { }


        #endregion Constructors


        #region Overrides (SPJobDefinition)


        /// <summary>
        /// This is the method that is invoked on the timer job to kick off its "work cycle."
        /// In the case of this timer job, we'll simply be making the call that will merge
        /// navigation info and shuffle around other "local content."
        /// </summary>
        /// <param name="targetInstanceId">A GUID that identifies the content database upon
        /// which the timer job is being run.  Since the timer job will be run at the web
        /// app level, we won't really need this parameter for anything.</param>
        [SharePointPermission(SecurityAction.LinkDemand, ObjectModel=true)]
        public override void Execute(Guid targetInstanceId)
        {
            // Begin by grabbing a reference to the housing SPWebApplication.  Assuming
            // we have a valid web app reference, we'll continue.
            SPWebApplication housingWebApp = this.Parent as SPWebApplication;
            if (housingWebApp != null)
            {
                // We need to load the sitemap file and grab each of the siteMapNode elements
                // that we'll be adding to each of the files.  As long as we can load the file
                // and have something to process, we can continue.
                List<XmlElement> nodesToAdd = LoadSitemapAdditions(_siteMapFilename);
                if (nodesToAdd.Count > 0)
                {
                    // In order to stay consistent with the SPWebService.ApplyApplicationContentToLocalServer
                    // method (which will likely be called for the SPWebService instance housing
                    // the current SPWebApplication at some point in the future), we should perform
                    // our nav merge at the same level (that is, process all SPWebApplication instances
                    // under the parent SPWebService).  Failure to do this could make for some future
                    // nav hierarchy "hmmm ... how did that happen?" experiences -- nothing serious, but
                    // possibly head-scratching.
                    ApplyAdditionsToService(housingWebApp.WebService, nodesToAdd);
                }
            }
       }


        #endregion Overrides (SPJobDefinition)


        #region Methods (Internal, Static)


        /// <summary>
        /// This static method is called by a FeatureReceiver upon activation in order to
        /// prepare and deploy one-time execution instances of this timer job class 
        /// (<see cref="UpdateLayoutsSitemapTimerJob"/>) to each of the servers in the farm.
        /// This method then iterates through the collection of servers in the farm and
        /// assigns new instances of this job to each server in turn.
        /// </summary>
        /// <param name="targetWebApp">A reference to the <c>SPWebApplication</c> for which
        /// activation is being performed.  It is the web sites contained within this web app
        /// that will have their sitemaps modified on each server in the farm.</param>
        /// <param name="siteMapFilename">A String that contains the name of the sitemap file
        /// (should be in the <c>layouts.sitemap.*.xml</c> format) that will be have its
        /// contents merged with the LAYOUTS nav hierarchy in each of the web sites contained
        /// with the <paramref name="targetWebApp"/>.</param>
        internal static void DeployUpdateTimerJobs(SPWebApplication targetWebApp,
                                                   String siteMapFilename)
        {
            // Assuming we have a valid SPWebApplication reference and filename, continue.
            if (targetWebApp != null && !String.IsNullOrEmpty(siteMapFilename))
            {
                // We need to iterate through each of the servers in the farm to target
                // them for timer job placement.  By creating a timer job with a locktype
                // of "None," an instance of the job will be established on each server in
                // the farm.
                //
                // Build title and name information for the jobs we're going to create.
                String jobName = String.Format(CultureInfo.InvariantCulture,
                                               FORMAT_JOB_NAME,
                                               targetWebApp.Id.ToString());
                String jobTitle = String.Format(CultureInfo.InvariantCulture,
                                                FORMAT_JOB_TITLE,
                                                targetWebApp.Name);

                // Create a timer job instance for the web application.
                UpdateLayoutsSitemapTimerJob newJob = new UpdateLayoutsSitemapTimerJob(jobName,
                                                                                       targetWebApp,
                                                                                       null,
                                                                                       SPJobLockType.None);

                // Assign the sitemap filename to the timer job.  When the timer job is
                // running on each box, it'll retrieve this value and go from there.
                newJob._siteMapFilename = siteMapFilename;

                // Establish a title and schedule (one-time) for the job and update it
                // to get it going.
                newJob.Title = jobTitle;
                newJob.Schedule = new SPOneTimeSchedule(DateTime.Now);
                newJob.Update(true);
            }
        }


        #endregion Methods (Internal, Static)


        #region Methods (Private, Static)


        /// <summary>
        /// This method is responsible for opening the sitemap file specified by the
        /// <paramref name="targetSiteMapFile"/> parameter, reading in its contents, and merging
        /// the <c>XmlElement</c> objects in the <paramref name="nodesToAdd"/> parameter with
        /// the file contents.  Once the merge is complete, the sitemap file is saved back out.
        /// </summary>
        /// <param name="nodesToAdd">A <c>List</c> containing each of the <c>XmlElement</c> siteMapNode
        /// nodes that need to be added to the sitemap hierarchy.  Existing nodes (that is, those nodes
        /// that match by <c>title</c> and <c>parentUrl</c>) are first removed.</param>
        /// <param name="targetSiteMapFile">A fully-qualified path to the target sitemap XML file which
        /// will be updated.</param>
        /// <remarks>This method assumes that the specified <paramref name="targetSiteMapFile"/> exists
        /// in the file system of the local server.</remarks>
        private static void AddNewNodesToSiteMap(List<XmlElement> nodesToAdd, String targetSiteMapFile)
        {
            // Begin by loading the sitemap into an XmlDocument for subsequent processing.
            XmlDocument siteMap = new XmlDocument();
            siteMap.Load(targetSiteMapFile);

            // We need to process each of the nodes that were passed in.
            foreach (XmlElement nodeToAdd in nodesToAdd)
            {
                // Extract the three attributes we need from the current node; if one
                // or more aren't present, we'll get a null reference back.
                String addParent = nodeToAdd.Attributes[XML_ATTRIBUTE_PARENT].Value;
                String addUrl = nodeToAdd.Attributes[XML_ATTRIBUTE_URL].Value;

                // We need non-null values for each of the above to process.
                if (addParent != null && addUrl != null)
                {
                    // Locate the parent node to which we'll be adding our node.  This needs to be done
                    // in a case-insensitive lookup, and our XPath is set to convert URLs to lowercase;
                    // we just need to be sure that the value from the sitemap file is also lowercase.
                    String xpathForParent = String.Format(XPATH_SELECT_PARENT, addParent.ToLowerInvariant());
                    XmlNode parentNode = siteMap.DocumentElement.SelectSingleNode(xpathForParent);

                    // We need a valid parent node to continue.
                    if (parentNode != null)
                    {
                        // Before we attempt to add the node, we need to make sure that any nodes that
                        // "match" the current one are found and removed.  A match is assumed anytime a
                        // siteMapNode is found that has the same URL and parent (regardless of title).
                        // We already have a reference to the parent node, so we simply have to locate
                        // and delete any nodes immediate children that have a URL matching the URL of
                        // the siteMapNode we intend to add.
                        //
                        // As before, ensure that the comparison is performed in a case-insensitive fashion.
                        String xpathForDelete = String.Format(XPATH_FOR_DELETE, addUrl.ToLowerInvariant());
                        XmlNodeList nodesToDelete = parentNode.SelectNodes(xpathForDelete);

                        // Process any matches we have.
                        foreach (XmlNode nodeToDelete in nodesToDelete)
                        {
                            parentNode.RemoveChild(nodeToDelete);
                        }

                        // Finally, add the current siteMapNode to the parent node.  We need to execute
                        // this with an import because the node to be added came from another XML document.
                        parentNode.AppendChild(siteMap.ImportNode(nodeToAdd, true));
                    }
                }
            }

            // Now that all nodes have been added, save the sitemap back to the file system.
            siteMap.Save(targetSiteMapFile);
        }


        /// <summary>
        /// This method is responsible for iterating through each of the
        /// <c>SPWebApplication</c> instances contained with <paramref name="targetService"/>
        /// to process the IIS sites contained within them.  "Process," in this case,
        /// means updating the sitemap nav hiearchy for LAYOUTS pages to include the
        /// (possibly new) links supplied by <paramref name="nodesToAdd"/>.
        /// </summary>
        /// <param name="targetService">A reference to the <c>SPWebService</c> for which
        /// the navigational changes/updates represented by <paramref name="nodesToAdd"/>
        /// should be processed.</param>
        /// <param name="nodesToAdd">A <c>List</c> of <c>XmlElement</c> objects that should
        /// be integrated into the LAYOUTS sitemap navigational hierarchy.</param>
        private static void ApplyAdditionsToService(SPWebService targetService, List<XmlElement> nodesToAdd)
        {
            // Establish the object that will track all files requiring modification.
            List<String> siteMapFiles = new List<String>();

            // To carry out the desired modifications, we need to process all web
            // apps in the target SPWebService.
            foreach (SPWebApplication currentWebApp in targetService.WebApplications)
            {
                // Each web application is going to have between one and five zones
                // associated with it, and each zone is going to have its own LAYOUTS
                // sitemap file.  The base path to each file can be determined by
                // using the SPIisSettings.Path setting for each zone.
                foreach (KeyValuePair<SPUrlZone, SPIisSettings> currentPair in currentWebApp.IisSettings)
                {
                    // The Dictionary value in the pair is the SPIisSettings object, and we'll use
                    // its path as a base to build the associated sitemap file path.  That fully-
                    // qualified filename will be added to our list of files to update.
                    siteMapFiles.Add(Path.Combine(currentPair.Value.Path.FullName, PATH_REL_SITEMAP));
                }
            }

            // At this point, we have a list of files that require processing.  We'll begin iterating
            // through the list.
            foreach (String targetSiteMapFile in siteMapFiles)
            {
                // Does the target sitemap file exist?  It should, but if we should find ourselves in
                // a scenario where it isn't present, we'll simply skip over it.
                if (File.Exists(targetSiteMapFile))
                {
                    // Begin by backing up the existing file in the same manner SharePoint does.
                    BackupSitemapFile(targetSiteMapFile);

                    // Merge the List of new siteMapNode elements with the existing sitemap structure.
                    AddNewNodesToSiteMap(nodesToAdd, targetSiteMapFile);
                }
            }
        }


        /// <summary>
        /// This method is used to emulate SharePoint's own sitemap file backup process.  It
        /// takes an existing sitemap file (typically in an _app_bin folder) and makes a copy
        /// of it with a new name that's based on the original + date/time information.
        /// </summary>
        /// <param name="targetSiteMapFile">A fully qualified path to the file that will be
        /// backed-up through the operation of this method.</param>
        /// <remarks>The algorithm for creating the backup filename is embedded within this
        /// method, along with constant information it requires.  Note, too, that it is assumed
        /// that the target file exists -- no existence check is performed.</remarks>
        private static void BackupSitemapFile(String targetSiteMapFile)
        {
            // The following constant defines the backup filename format.
            const String BACKUPNAME = "{0}.{1}_{2}_{3}_{4}_{5}{6}";

            // Begin by dissecting the file.  We need the path, base filename, and extension
            // information for further processing.
            String workingDir = Path.GetDirectoryName(targetSiteMapFile);
            String workingFileBase = Path.GetFileNameWithoutExtension(targetSiteMapFile);
            String workingExtension = Path.GetExtension(targetSiteMapFile);

            // Grab the current date/time, as it will be used for most of the insertion
            // operations on the backup filename.
            DateTime dtNow = DateTime.Now;

            // Build the backup name in its entirety.  The resultant filename will look
            // something like "layouts.2009_5_28_23_42.sitemap"
            String backupFile = String.Format(CultureInfo.InvariantCulture,
                                              BACKUPNAME,
                                              workingFileBase,
                                              dtNow.Year.ToString(DateTimeFormatInfo.InvariantInfo),
                                              dtNow.Month.ToString(DateTimeFormatInfo.InvariantInfo),
                                              dtNow.Day.ToString(DateTimeFormatInfo.InvariantInfo),
                                              dtNow.Hour.ToString(DateTimeFormatInfo.InvariantInfo),
                                              dtNow.Minute.ToString(DateTimeFormatInfo.InvariantInfo),
                                              workingExtension);
                                              
            // Combine the backup filename just constructed with the working directory to get
            // a fully-qualified backup path.
            String fullBackupPath = Path.Combine(workingDir, backupFile);

            // Copy the source site map file to the backup specification, overwriting if necessary.
            File.Copy(targetSiteMapFile, fullBackupPath, true);
        }


        /// <summary>
        /// This method is responsible for processing the contents of the specified
        /// sitemap addition file and processing them into a list of nodes that can 
        /// then be added to each of the site-level sitemap files.
        /// </summary>
        /// <param name="siteMapFilename">A string containing the basic filename of
        /// the sitemap file to be parsed and returned.  It is assumed that this file
        /// resides in the LAYOUTS folder on the local server.</param>
        /// <returns>A <c>List</c> containing each of the <c>siteMapNode</c>
        /// elements that will be added to the site sitemap files.</returns>
        private static List<XmlElement> LoadSitemapAdditions(String siteMapFilename)
        {
            // Prepare the node list that will be returned.
            List<XmlElement> nodesToAdd = new List<XmlElement>();

            // The first step is to map to the file.  The SPUtility class can be used
            // to get us to the 12 hive's LAYOUTS folder; from there, we map to our
            // desired sitemap file.
            String layoutsBasePath = SPUtility.GetGenericSetupPath(PATH_REL_LAYOUTS);
            String fullPathToSiteMap = Path.Combine(layoutsBasePath, siteMapFilename);

            // Assuming we have a file, we can continue.
            if (File.Exists(fullPathToSiteMap))
            {
                // We'll burn through the file and extract all relevant elements.  Build
                // the doc and grab the elements needed for processing.
                XmlDocument addItemsDoc = new XmlDocument();
                addItemsDoc.Load(fullPathToSiteMap);
                XmlNodeList newSiteMapNodes = addItemsDoc.SelectNodes(XPATH_SELECT_ADD_NODES);

                // Iterate through each of the matches and add them to the return list.
                foreach (XmlNode currentNode in newSiteMapNodes)
                {
                    nodesToAdd.Add(currentNode as XmlElement);
                }
            }

            // Return the results of the lookup.
            return nodesToAdd;
        }


        #endregion Methods (Private, Static)


    }
}